Java — 垃圾收集灵魂拷问三连(一)

前言

说到GC,一直没有系统的看过。但是,很显然,是一个非常经典的知识点。毫不夸张的说,面试的时候问道GC而你一无所知的话,基本上是凉了。

今天拜读了JVM经典书《深入理解Java虚拟机》,对于GC讲的很详细。书中在第三章讲的是垃圾收集器与内存分配策略,我准备分三篇文章来记录这章的读书笔记,本篇介绍一下垃圾回收流程,第二篇介绍垃圾收集器,第三篇介绍新老生代的划分以及内存分配策略。本篇更偏向于理论和算法思想,所以看起来还是很有趣的。值得注意的是,我们只有在程序处于运行期间才知道创建了哪些对象,这部分内存的分配和回收是动态的,垃圾收集关注的也是这部分内存。学习GC通俗来说也就三个问题:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收

下面,我们也根据这三个问题来逐步了解GC。

哪些内存需要回收?

对象占用内存,所以不再使用的对象所占用的内存需要回收。那么,怎么判断对象不再使用呢?判断对象是否 “死亡” 有以下方法:

  1. 引用计数算法

    给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器都为0的对象就是不可能在被使用的。引用计数算法实现简单,判断效率也很高,在大部分情况下都是一个不错的算法。但是,在Java中并没有选择引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间的相互循环引用的问题。

  2. 根搜索算法

    Java中,是用根搜索算法来判断对象是否存活的。这个算法的思路就是通过一系列的名为 “GC Roots” 的对象作为起始点,从这个节点开始向下搜索,搜索所有走过的路径称为引用链(Reference Chain),当一个对象到了GC Roots没有任何引用链相连的时候(不可达),则说明此对象是不可用的,被判定为可回收对象。

    一图胜千言:(图是枪来的……)

  3. 根据引用判断

    无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判断对象是否存活都与引用有关。

    • 强引用(Strong Reference)

      在代码中普遍存在,类似 Object obj = new Object() 。只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

    • 软引用(Soft Reference)

      用来描述一些还有用,但并非必须的对象。对于软引用关联的对象,在系统将要发生OOM异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类实现软引用。

    • 弱引用(Weak Reference)

      也是用来描述非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只有被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类实现弱引用。

    • 虚引用(Phantom Reference)

      它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

什么时候回收?

当一个对象不可达时,并不是马上就会被回收的。这里就要说一下finalize()方法,这个方法被调用有三种情况:

  1. 所有对象被CG时自动调用,比如运行System.gc()的时候
  2. 程序退出时为每个对象调用一次finalize方法
  3. 显式的调用finalize方法

再盗一张图:

JVM 能够保证一个对象在回收以前一定会调用一次它的finalize()方法。

需要注意的是,你永远不知道它什么时候被调用甚至会不会调用,因为有些对象永远不会被回收的,或者被回收以前程序就已经结束了。但是如果它有必要执行finalize()的,那么在GC前一定调用一次且仅且一次,如果在第一次GC时没有被回收,那么以后在GC时就不会在调用finalize()。

如何回收?

那就要说说回收算法啦。

  1. 标记清除算法(Mark-Sweep)

    算法分为标记和清除两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收掉被标记的对象。

    缺点:

    • 效率低下,标记和清除的效率都不高
    • 空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致需要分配大对象无法找到足够的连续内存而不得不提前触发另一次垃圾收集。

  2. 复制算法(Copying)

    它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活的对象复制到另外一块上面,然后再把已经使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配也不用考虑内存碎片等复杂问题,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

    当然,缺点也很明显,那就是要牺牲一半的内存代价。

    但是事实上,并不需要按1:1划分,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor空间还存活的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和刚刚用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。但是,当对象存活率较高时就要执行较多的复制操作,效率将会变低。

  3. 标记-整理算法(Mark-Compact)

    此算法结合了标记清除和复制两个算法的优点,分为两个阶段:

    1. 第一阶段从根节点开始标记所有被引用的对象
    2. 第二阶段遍历整个堆,把清除未标记对象并且把存活的对象压缩到堆的其中一块,按顺序排放。此算法避免了标记清除的碎片问题,同时也避免了复制算法的空间问题。

  4. 分代收集算法(Generational Collection)

    当前商业虚拟机的垃圾回收都采用分代收集算法,这种算法并没有什么新的思想,只是根据对象的存活周期将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,那就选用复制算法,只要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外的空间对它进行分配担保,那就使用标记清理或者标记整理算法来进行回收。

    关于新生代和老年代的划分,我们在下下一篇文章内存分配策略中会讲到。

更多参考:

本篇文章的的部分内容和图片采自以下博客,感谢!

浅析JAVA的垃圾回收机制(GC)

GC及JVM参数

我们一直都向往,面朝大海,春暖花开。 但是几人能做到,心中有爱,四季不败?